Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 8ade296fb16b8f8e145f0c83e97179c456325be3


Parents : 33db12b
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-14T19:33:37-05:00

feat(frontend): improve UI with loading overlay, legend, and toolbar components for better user experience

Changes
Diff

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index a1587457..56e6239f 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -14,6 +14,23 @@
</div>
</div>
+ <div
+ v-if="showWsDisconnectedBanner"
+ class="relative z-[100] bg-red-700 text-white px-4 py-2 text-center text-sm font-medium shadow-md border-b border-red-800/80"
+ role="status"
+ aria-live="polite"
+ >
+ {{ $t("app.backend_disconnected") }} · {{ wsDisconnectedDurationText }}
+ </div>
+ <div
+ v-if="wsReconnectedBanner"
+ class="relative z-[100] bg-emerald-700 text-white px-4 py-2 text-center text-sm font-medium shadow-md border-b border-emerald-800/80 transition-opacity duration-300"
+ role="status"
+ aria-live="polite"
+ >
+ {{ $t("app.backend_reconnected") }}
+ </div>
+
<RouterView v-if="$route.name === 'auth'" />
<template v-else>
@@ -573,6 +590,7 @@ import { useTheme } from "vuetify";
import SidebarLink from "./SidebarLink.vue";
import DialogUtils from "../js/DialogUtils";
import WebSocketConnection from "../js/WebSocketConnection";
+import { formatDisconnectedDuration } from "../js/wsConnectionSupport";
import GlobalState, { mergeGlobalConfig } from "../js/GlobalState";
import Utils from "../js/Utils";
import GlobalEmitter from "../js/GlobalEmitter";
@@ -657,6 +675,13 @@ export default {
initiationTargetHash: null,
initiationTargetName: null,
isCallWindowOpen: false,
+
+ wsDisconnected: false,
+ wsDisconnectedAt: null,
+ wsDisconnectedDurationText: "",
+ wsReconnectedBanner: false,
+ wsDisconnectTickTimer: null,
+ wsReconnectedHideTimer: null,
};
},
computed: {
@@ -685,6 +710,9 @@ export default {
activeCallTab() {
return GlobalState.activeCallTab;
},
+ showWsDisconnectedBanner() {
+ return this.shellRunning && this.wsDisconnected && this.$route?.name !== "auth";
+ },
},
watch: {
$route(to, from) {
@@ -715,6 +743,7 @@ export default {
this._shellAuthWatchStop = null;
}
this.stopShell();
+ this.clearWsShellUiTimers();
if (this.endedTimeout) clearTimeout(this.endedTimeout);
this.stopRingtone();
this.toneGenerator.stop();
@@ -769,6 +798,8 @@ export default {
this.shellRunning = true;
WebSocketConnection.connect();
WebSocketConnection.on("message", this.onWebsocketMessage);
+ WebSocketConnection.on("disconnected", this.onWsShellDisconnected);
+ WebSocketConnection.on("connected", this.onWsShellConnected);
GlobalEmitter.on("identity-switching-start", this.onIdentitySwitchingStartShell);
GlobalEmitter.on("sync-propagation-node", this.onSyncPropagationNodeShell);
GlobalEmitter.on("config-updated", this.onConfigUpdatedExternally);
@@ -803,6 +834,8 @@ export default {
clearInterval(this.appInfoInterval);
this.appInfoInterval = null;
WebSocketConnection.off("message", this.onWebsocketMessage);
+ WebSocketConnection.off("disconnected", this.onWsShellDisconnected);
+ WebSocketConnection.off("connected", this.onWsShellConnected);
GlobalEmitter.off("identity-switching-start", this.onIdentitySwitchingStartShell);
GlobalEmitter.off("sync-propagation-node", this.onSyncPropagationNodeShell);
GlobalEmitter.off("config-updated", this.onConfigUpdatedExternally);
@@ -810,8 +843,104 @@ export default {
GlobalEmitter.off("block-status-changed", this.onBlockStatusChangedShell);
GlobalEmitter.off("show-changelog", this.onShowChangelogShell);
GlobalEmitter.off("show-tutorial", this.onShowTutorialShell);
+ this.clearWsShellUiTimers();
+ this.wsDisconnected = false;
+ this.wsDisconnectedAt = null;
+ this.wsDisconnectedDurationText = "";
+ this.wsReconnectedBanner = false;
WebSocketConnection.destroy();
},
+ clearWsShellUiTimers() {
+ if (this.wsDisconnectTickTimer != null) {
+ clearInterval(this.wsDisconnectTickTimer);
+ this.wsDisconnectTickTimer = null;
+ }
+ if (this.wsReconnectedHideTimer != null) {
+ clearTimeout(this.wsReconnectedHideTimer);
+ this.wsReconnectedHideTimer = null;
+ }
+ },
+ onWsShellDisconnected() {
+ if (!this.shellRunning) {
+ return;
+ }
+ this.wsDisconnected = true;
+ this.wsDisconnectedAt = Date.now();
+ this._tickWsDisconnectedLabel();
+ if (this.wsDisconnectTickTimer != null) {
+ clearInterval(this.wsDisconnectTickTimer);
+ }
+ this.wsDisconnectTickTimer = setInterval(() => this._tickWsDisconnectedLabel(), 1000);
+ },
+ _tickWsDisconnectedLabel() {
+ if (!this.wsDisconnectedAt) {
+ this.wsDisconnectedDurationText = "";
+ return;
+ }
+ this.wsDisconnectedDurationText = formatDisconnectedDuration(Date.now() - this.wsDisconnectedAt);
+ },
+ async onWsShellConnected(payload = {}) {
+ if (!this.shellRunning) {
+ return;
+ }
+ this.wsDisconnected = false;
+ this.wsDisconnectedAt = null;
+ this.wsDisconnectedDurationText = "";
+ if (this.wsDisconnectTickTimer != null) {
+ clearInterval(this.wsDisconnectTickTimer);
+ this.wsDisconnectTickTimer = null;
+ }
+ const isReconnect = payload.isReconnect === true;
+ if (isReconnect) {
+ await this.resyncShellAfterWebsocketReconnect();
+ this.wsReconnectedBanner = true;
+ if (this.wsReconnectedHideTimer != null) {
+ clearTimeout(this.wsReconnectedHideTimer);
+ }
+ this.wsReconnectedHideTimer = setTimeout(() => {
+ this.wsReconnectedBanner = false;
+ this.wsReconnectedHideTimer = null;
+ }, 4500);
+ }
+ },
+ async resyncShellAfterWebsocketReconnect() {
+ try {
+ await this.getAppInfo();
+ } catch {
+ // ignore
+ }
+ try {
+ await this.getConfig();
+ } catch {
+ // ignore
+ }
+ try {
+ await this.getBlockedDestinations();
+ } catch {
+ // ignore
+ }
+ try {
+ await this.getKeyboardShortcuts();
+ } catch {
+ // ignore
+ }
+ try {
+ await this.updateRingtonePlayer();
+ } catch {
+ // ignore
+ }
+ try {
+ await this.updateTelephoneStatus();
+ } catch {
+ // ignore
+ }
+ try {
+ await this.updatePropagationNodeStatus();
+ } catch {
+ // ignore
+ }
+ GlobalEmitter.emit("websocket-reconnected");
+ },
onIdentitySwitchingStartShell() {
this.isSwitchingIdentity = true;
setTimeout(() => {

diff --git a/meshchatx/src/frontend/components/CommandPalette.vue b/meshchatx/src/frontend/components/CommandPalette.vue
index eee01be3..d2b481d4 100644
--- a/meshchatx/src/frontend/components/CommandPalette.vue
+++ b/meshchatx/src/frontend/components/CommandPalette.vue
@@ -116,6 +116,14 @@ import LxmfUserIcon from "./LxmfUserIcon.vue";
import GlobalEmitter from "../js/GlobalEmitter";
import ToastUtils from "../js/ToastUtils";
+const VISUALISER_ONLY_ACTIONS = new Set([
+ "toggle-orbit",
+ "toggle-bouncing-balls",
+ "toggle-falling-skies",
+ "toggle-snake",
+ "toggle-pong",
+]);
+
export default {
name: "CommandPalette",
components: { MaterialDesignIcon, LxmfUserIcon },
@@ -303,6 +311,30 @@ export default {
type: "action",
action: "toggle-bouncing-balls",
},
+ {
+ id: "action-falling-skies",
+ title: "action_falling_skies",
+ description: "action_falling_skies_desc",
+ icon: "weather-pouring",
+ type: "action",
+ action: "toggle-falling-skies",
+ },
+ {
+ id: "action-snake",
+ title: "action_snake",
+ description: "action_snake_desc",
+ icon: "snake",
+ type: "action",
+ action: "toggle-snake",
+ },
+ {
+ id: "action-pong",
+ title: "action_pong",
+ description: "action_pong_desc",
+ icon: "table-tennis",
+ type: "action",
+ action: "toggle-pong",
+ },
{
id: "action-getting-started",
title: "action_getting_started",
@@ -324,11 +356,19 @@ export default {
},
computed: {
allResults() {
- const results = this.actions.map((action) => ({
- ...action,
- title: this.$t(`command_palette.${action.title}`),
- description: this.$t(`command_palette.${action.description}`),
- }));
+ const onVisualiser = this.$route?.name === "network-visualiser";
+ const results = this.actions
+ .filter((action) => {
+ if (action.type === "action" && VISUALISER_ONLY_ACTIONS.has(action.action)) {
+ return onVisualiser;
+ }
+ return true;
+ })
+ .map((action) => ({
+ ...action,
+ title: this.$t(`command_palette.${action.title}`),
+ description: this.$t(`command_palette.${action.description}`),
+ }));
// add peers
if (Array.isArray(this.peers)) {
@@ -477,6 +517,12 @@ export default {
GlobalEmitter.emit("toggle-orbit");
} else if (result.action === "toggle-bouncing-balls") {
GlobalEmitter.emit("toggle-bouncing-balls");
+ } else if (result.action === "toggle-falling-skies") {
+ GlobalEmitter.emit("toggle-falling-skies");
+ } else if (result.action === "toggle-snake") {
+ GlobalEmitter.emit("toggle-snake");
+ } else if (result.action === "toggle-pong") {
+ GlobalEmitter.emit("toggle-pong");
} else if (result.action === "show-tutorial") {
GlobalEmitter.emit("show-tutorial");
} else if (result.action === "show-changelog") {

diff --git a/meshchatx/src/frontend/components/debug/DebugLogsPage.vue b/meshchatx/src/frontend/components/debug/DebugLogsPage.vue
index ba592973..88ec0748 100644
--- a/meshchatx/src/frontend/components/debug/DebugLogsPage.vue
+++ b/meshchatx/src/frontend/components/debug/DebugLogsPage.vue
@@ -1,15 +1,15 @@
<template>
- <div
- class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
- >
- <div class="flex flex-col h-full overflow-hidden w-full px-4 md:px-5 lg:px-8 py-6">
- <div class="flex flex-col mb-4 w-full max-w-6xl mx-auto space-y-4">
- <div class="flex items-center justify-between">
- <div class="space-y-1">
+ <div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
+ <div
+ class="flex flex-col h-full overflow-hidden w-full px-4 md:px-5 lg:px-8 py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]"
+ >
+ <div class="flex flex-col mb-4 w-full max-w-6xl mx-auto space-y-4 min-w-0">
+ <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between min-w-0">
+ <div class="space-y-1 min-w-0">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Diagnostics</div>
- <div class="text-3xl font-semibold text-gray-900 dark:text-white">Debug Logs</div>
+ <div class="text-2xl sm:text-3xl font-semibold text-gray-900 dark:text-white">Debug Logs</div>
</div>
- <div class="flex gap-2">
+ <div class="flex flex-wrap gap-2 shrink-0">
<button type="button" class="secondary-chip px-4 py-2 text-sm" @click="refreshActive">
<MaterialDesignIcon icon-name="refresh" class="w-4 h-4" />
Refresh
@@ -21,10 +21,12 @@
</div>
</div>
- <div class="flex flex-wrap gap-2 border-b border-gray-200 dark:border-zinc-700 pb-2">
+ <div
+ class="flex flex-nowrap sm:flex-wrap gap-2 border-b border-gray-200 dark:border-zinc-700 pb-2 overflow-x-auto overscroll-x-contain -mx-4 px-4 sm:mx-0 sm:px-0 no-scrollbar"
+ >
<button
type="button"
- class="px-4 py-2 text-sm rounded-md transition-colors"
+ class="shrink-0 px-4 py-2 text-sm rounded-md transition-colors"
:class="
activeTab === 'logs'
? 'bg-blue-600 text-white'
@@ -36,7 +38,7 @@
</button>
<button
type="button"
- class="px-4 py-2 text-sm rounded-md transition-colors"
+ class="shrink-0 px-4 py-2 text-sm rounded-md transition-colors"
:class="
activeTab === 'access'
? 'bg-blue-600 text-white'

diff --git a/meshchatx/src/frontend/components/forwarder/ForwarderPage.vue b/meshchatx/src/frontend/components/forwarder/ForwarderPage.vue
index 9761d00b..c7b2f78b 100644
--- a/meshchatx/src/frontend/components/forwarder/ForwarderPage.vue
+++ b/meshchatx/src/frontend/components/forwarder/ForwarderPage.vue
@@ -1,8 +1,6 @@
<template>
- <div
- class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
- >
- <div class="flex-1 overflow-y-auto w-full">
+ <div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
+ <div class="flex-1 overflow-y-auto w-full pb-[max(1.5rem,env(safe-area-inset-bottom))]">
<div class="space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
<div class="glass-card space-y-3">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
@@ -228,9 +226,3 @@ export default {
},
};
</script>
-
-<style scoped>
-.glass-card {
- @apply bg-white/80 dark:bg-zinc-900/80 backdrop-blur-md border border-gray-200 dark:border-zinc-800 p-6 rounded-3xl shadow-sm;
-}
-</style>

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index b0dc3219..3b322b3b 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -736,8 +736,40 @@
<span>{{ formatAttachmentSize(chatItem.lxmf_message.fields.image, "image") }}</span>
</div>
</div>
+ <!-- image-only: inline timestamp overlay (no bubble) -->
+ <div
+ v-if="isImageOnlyMessage(chatItem)"
+ class="flex items-center gap-1.5 select-none mt-0.5"
+ :class="chatItem.is_outbound ? 'justify-end' : 'justify-start'"
+ >
+ <span class="text-[9px] opacity-50 font-medium">
+ {{ formatTimeAgo(chatItem.lxmf_message.created_at) }}
+ </span>
+ <template v-if="chatItem.is_outbound">
+ <MaterialDesignIcon
+ v-if="chatItem.lxmf_message.state === 'delivered'"
+ icon-name="check-all"
+ class="size-3 opacity-50"
+ />
+ <MaterialDesignIcon
+ v-else-if="['sent', 'propagated', 'unknown'].includes(chatItem.lxmf_message.state)"
+ icon-name="check"
+ class="size-3 opacity-50"
+ />
+ <span
+ v-else-if="
+ ['failed', 'cancelled', 'rejected'].includes(chatItem.lxmf_message.state)
+ "
+ class="text-[9px] font-bold uppercase tracking-wider text-red-500"
+ >
+ {{ chatItem.lxmf_message.state === "rejected" ? "Rejected" : "Failed" }}
+ </span>
+ </template>
+ </div>
+
<!-- message content -->
<div
+ v-if="!isImageOnlyMessage(chatItem)"
class="relative rounded-2xl overflow-hidden transition-all duration-200 hover:shadow-md min-w-0"
:class="[
['cancelled', 'failed'].includes(chatItem.lxmf_message.state)
@@ -2590,6 +2622,7 @@ export default {
void GlobalState.detailedOutboundSendStatus;
void this.sendStatusUiMs;
void this.usesThemeOutboundBubbleColor;
+ void GlobalState.config?.theme;
const useThemeOutbound = this.usesThemeOutboundBubbleColor;
return (chatItem) => {
const styles = {};
@@ -2608,7 +2641,11 @@ export default {
styles["color"] = "#ffffff";
} else if (chatItem.is_outbound) {
if (chatItem.lxmf_message?._pendingPathfinding) {
- const hex = cfg?.message_waiting_bubble_color || "#e5e7eb";
+ const raw = cfg?.message_waiting_bubble_color;
+ let hex = raw != null && String(raw).trim() !== "" ? String(raw).trim() : "#e5e7eb";
+ if (cfg?.theme === "dark" && /^#e5e7eb$/i.test(hex)) {
+ hex = "#3f3f46";
+ }
styles["background-color"] = hex;
styles["color"] = this.pickTextColorForBubbleBackground(hex);
styles["border"] = this.waitingBubbleBorderForHex(hex);
@@ -2752,6 +2789,10 @@ export default {
return false;
}
+ if (!this.hasRenderableContent(chatItem.lxmf_message)) {
+ return false;
+ }
+
return true;
}
@@ -5036,6 +5077,26 @@ export default {
return !hasContent && !hasAttachments && (hasTelemetry || hasCommands);
},
+ hasRenderableContent(msg) {
+ if (msg.content && msg.content.trim() !== "") return true;
+ if (msg.fields?.image) return true;
+ if (msg.fields?.audio) return true;
+ if (msg.fields?.file_attachments) return true;
+ if (msg.fields?.telemetry || msg.fields?.telemetry_stream) return true;
+ if (msg.fields?.commands && msg.fields.commands.some((c) => c["0x01"] || c["1"] || c["0x1"])) return true;
+ return false;
+ },
+ isImageOnlyMessage(chatItem) {
+ const msg = chatItem.lxmf_message;
+ if (!msg.fields?.image) return false;
+ if (msg.fields?.audio || msg.fields?.file_attachments) return false;
+ const content = (msg.content || "").trim();
+ if (content && !this.shouldHideAutoImageCaption(chatItem)) return false;
+ if (msg.reply_to_hash) return false;
+ if (msg.fields?.telemetry || msg.fields?.telemetry_stream) return false;
+ if (msg.fields?.commands && msg.fields.commands.some((c) => c["0x01"] || c["1"] || c["0x1"])) return false;
+ return true;
+ },
async toggleTracking() {
if (!this.selectedPeer) return;
const hash = this.selectedPeer.destination_hash;

diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index e4556777..704c5f19 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -155,6 +155,9 @@ export default {
return {
reloadInterval: null,
conversationRefreshTimeout: null,
+ peersRefreshTimeout: null,
+ conversationsAbortController: null,
+ announcesAbortController: null,
config: snapshotGlobalConfig(),
hasLoadedConversations: false,
@@ -214,16 +217,21 @@ export default {
beforeUnmount() {
clearInterval(this.reloadInterval);
clearTimeout(this.conversationRefreshTimeout);
+ clearTimeout(this.peersRefreshTimeout);
+ this.conversationsAbortController?.abort();
+ this.announcesAbortController?.abort();
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("compose-new-message", this.onComposeNewMessage);
GlobalEmitter.off("refresh-conversations", this.requestConversationsRefresh);
+ GlobalEmitter.off("websocket-reconnected", this.requestConversationsRefresh);
},
mounted() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
GlobalEmitter.on("compose-new-message", this.onComposeNewMessage);
+ GlobalEmitter.on("websocket-reconnected", this.requestConversationsRefresh);
this.getConfig();
this.getConversations();
@@ -350,6 +358,14 @@ export default {
},
async getLxmfDeliveryAnnounces(append = false) {
try {
+ if (!append) {
+ if (this.announcesAbortController) {
+ this.announcesAbortController.abort();
+ }
+ this.announcesAbortController = new AbortController();
+ } else if (!this.announcesAbortController) {
+ this.announcesAbortController = new AbortController();
+ }
const offset = append ? Object.keys(this.peers).length : 0;
const response = await window.api.get(`/api/v1/announces`, {
params: {
@@ -358,6 +374,7 @@ export default {
offset: offset,
search: this.peersSearchTerm,
},
+ signal: this.announcesAbortController.signal,
});
const newAnnounces = response.data.announces;
@@ -373,6 +390,7 @@ export default {
this.hasMoreAnnounces = newAnnounces.length === this.pageSize;
} catch (e) {
+ if (window.api.isCancel?.(e)) return;
console.log(e);
} finally {
this.isLoadingMoreAnnounces = false;
@@ -405,6 +423,14 @@ export default {
},
async getConversations(append = false) {
try {
+ if (!append) {
+ if (this.conversationsAbortController) {
+ this.conversationsAbortController.abort();
+ }
+ this.conversationsAbortController = new AbortController();
+ } else if (!this.conversationsAbortController) {
+ this.conversationsAbortController = new AbortController();
+ }
const shouldShowInitialLoading =
!append && !this.hasLoadedConversations && this.conversations.length === 0;
if (shouldShowInitialLoading) {
@@ -418,6 +444,7 @@ export default {
limit: this.pageSize,
offset: offset,
},
+ signal: this.conversationsAbortController.signal,
});
const newConversations = response.data.conversations;
@@ -453,6 +480,7 @@ export default {
this.hasLoadedConversations = true;
this.hasMoreConversations = newConversations.length === this.pageSize;
} catch (e) {
+ if (window.api.isCancel?.(e)) return;
console.log(e);
} finally {
this.isLoadingConversations = false;

diff --git a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
index a3ac1aad..29d2c174 100644
--- a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
+++ b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
@@ -2,9 +2,9 @@
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
<!-- Compact Header -->
<div
- class="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-900/50 backdrop-blur-sm shrink-0"
+ class="flex flex-wrap items-center justify-between gap-2 px-3 sm:px-4 py-2 border-b border-gray-200 dark:border-zinc-800 bg-slate-50/95 dark:bg-zinc-950/95 backdrop-blur-sm shrink-0 min-w-0"
>
- <div class="flex items-center gap-3">
+ <div class="flex items-center gap-2 sm:gap-3 min-w-0">
<div class="bg-teal-100 dark:bg-teal-900/30 p-1.5 rounded-xl shrink-0">
<MaterialDesignIcon icon-name="code-tags" class="size-5 text-teal-600 dark:text-teal-400" />
</div>
@@ -89,7 +89,7 @@
<!-- Tab Bar -->
<div
- class="flex items-center px-4 py-1 gap-1 border-b border-gray-200 dark:border-zinc-800 bg-slate-100 dark:bg-zinc-900 overflow-x-auto scrollbar-hide shrink-0"
+ class="flex items-center px-3 sm:px-4 py-1 gap-1 border-b border-gray-200 dark:border-zinc-800 bg-slate-100 dark:bg-zinc-900 overflow-x-auto no-scrollbar shrink-0"
>
<div
v-for="(tab, index) in tabs"
@@ -128,7 +128,7 @@
</button>
</div>
- <div class="flex-1 flex overflow-hidden">
+ <div class="flex-1 flex overflow-hidden min-w-0 pb-[env(safe-area-inset-bottom)]">
<!-- Editor Pane -->
<div
v-if="tabs.length > 0"
@@ -892,12 +892,4 @@ ${b}=
:deep(a:hover) {
text-decoration: underline;
}
-
-.scrollbar-hide::-webkit-scrollbar {
- display: none;
-}
-.scrollbar-hide {
- -ms-overflow-style: none;
- scrollbar-width: none;
-}
</style>

diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index 79bc6de0..6037be6a 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -3,287 +3,39 @@
<!-- network -->
<div id="network" class="w-full h-full"></div>
- <!-- loading overlay -->
- <div
- v-if="isLoading"
- class="absolute inset-0 z-20 flex items-center justify-center bg-zinc-950/10 backdrop-blur-[2px] transition-all duration-300"
- >
- <div
- class="bg-white/90 dark:bg-zinc-900/90 border border-gray-200 dark:border-zinc-800 rounded-2xl px-6 py-4 flex flex-col items-center gap-3"
- >
- <div class="relative">
- <div
- class="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
- ></div>
- <div class="absolute inset-0 flex items-center justify-center">
- <div
- class="w-6 h-6 border-4 border-emerald-500/20 border-b-emerald-500 rounded-full animate-spin-reverse"
- ></div>
- </div>
- </div>
- <div class="text-sm font-medium text-gray-900 dark:text-zinc-100">{{ loadingStatus }}</div>
- <div v-if="totalNodesToLoad > 0" class="w-48 space-y-2">
- <div class="h-1.5 bg-gray-200 dark:bg-zinc-800 rounded-full overflow-hidden">
- <div
- class="h-full bg-blue-500 transition-all duration-300 shadow-[0_0_8px_rgba(59,130,246,0.5)]"
- :style="{ width: `${(loadedNodesCount / totalNodesToLoad) * 100}%` }"
- ></div>
- </div>
- <div
- v-if="totalBatches > 0"
- class="flex justify-between items-center text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider"
- >
- <span>{{ $t("visualiser.batch") }} {{ currentBatch }} / {{ totalBatches }}</span>
- <span>{{ Math.round((loadedNodesCount / totalNodesToLoad) * 100) }}%</span>
- </div>
- </div>
- </div>
- </div>
-
- <!-- controls & search -->
- <div
- class="absolute top-2 left-2 right-2 sm:top-4 sm:left-4 sm:right-4 z-10 flex flex-col sm:flex-row gap-2 pointer-events-none"
- >
- <!-- header glass card -->
- <div
- class="pointer-events-auto border border-gray-200/50 dark:border-zinc-800/50 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl rounded-2xl overflow-hidden w-full sm:min-w-[280px] sm:w-auto transition-all duration-300"
- >
- <div
- class="flex items-center px-4 sm:px-5 py-3 sm:py-4 cursor-pointer hover:bg-gray-50/50 dark:hover:bg-zinc-800/50 transition-colors"
- @click="isShowingControls = !isShowingControls"
- >
- <div class="flex-1 flex flex-col min-w-0 mr-2">
- <span class="font-bold text-gray-900 dark:text-zinc-100 tracking-tight truncate">{{
- $t("visualiser.reticulum_mesh")
- }}</span>
- <span
- class="text-[10px] uppercase font-bold text-gray-500 dark:text-zinc-500 tracking-widest truncate"
- >{{ $t("visualiser.network_visualizer") }}</span
- >
- </div>
- <div class="flex items-center gap-2">
- <button
- type="button"
- class="inline-flex items-center justify-center w-8 h-8 sm:w-9 sm:h-9 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white transition-all active:scale-95"
- :disabled="isUpdating || isLoading"
- @click.stop="manualUpdate"
- >
- <svg
- v-if="!isUpdating && !isLoading"
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="w-4 h-4 sm:w-5 sm:h-5"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
- />
- </svg>
- <svg
- v-else
- class="animate-spin h-4 w-4 sm:h-5 sm:w-5"
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- >
- <circle
- class="opacity-25"
- cx="12"
- cy="12"
- r="10"
- stroke="currentColor"
- stroke-width="4"
- ></circle>
- <path
- class="opacity-75"
- fill="currentColor"
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
- ></path>
- </svg>
- </button>
- <div class="w-5 sm:w-6 flex justify-center">
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 20 20"
- fill="currentColor"
- class="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 transition-transform duration-300"
- :class="{ 'rotate-180': isShowingControls }"
- >
- <path
- fill-rule="evenodd"
- d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
- clip-rule="evenodd"
- />
- </svg>
- </div>
- </div>
- </div>
-
- <div
- v-show="isShowingControls"
- class="px-5 pb-5 space-y-4 animate-in fade-in slide-in-from-top-2 duration-300"
- >
- <!-- divider -->
- <div
- class="h-px bg-gradient-to-r from-transparent via-gray-200 dark:via-zinc-800 to-transparent"
- ></div>
-
- <!-- auto update toggle -->
- <div class="flex items-center justify-between">
- <label
- for="auto-reload"
- class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
- >Auto Update</label
- >
- <Toggle id="auto-reload" v-model="autoReload" />
- </div>
-
- <!-- physics toggle -->
- <div class="flex items-center justify-between">
- <label
- for="enable-physics"
- class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
- >Live Layout</label
- >
- <Toggle id="enable-physics" v-model="enablePhysics" />
- </div>
-
- <!-- max hops filter -->
- <div class="space-y-2">
- <div class="flex items-center justify-between gap-2">
- <label
- for="hop-filter-slider"
- class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
- >{{ $t("visualiser.max_hops_filter") }}</label
- >
- <span
- class="text-xs font-bold text-blue-600 dark:text-blue-400 tabular-nums min-w-[4rem] text-right"
- >{{ hopFilterSlider === 0 ? $t("visualiser.all") : hopFilterSlider }}</span
- >
- </div>
- <input
- id="hop-filter-slider"
- v-model.number="hopFilterSlider"
- type="range"
- min="0"
- :max="hopSliderMax"
- step="1"
- class="w-full h-2 rounded-lg appearance-none cursor-pointer bg-gray-200 dark:bg-zinc-700 accent-blue-600 dark:accent-blue-500"
- />
- </div>
-
- <!-- stats -->
- <div class="grid grid-cols-2 gap-3 pt-2">
- <div
- class="bg-gray-50/50 dark:bg-zinc-800/50 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
- >
- <div
- class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-1"
- >
- Nodes
- </div>
- <div class="text-lg font-bold text-blue-600 dark:text-blue-400">{{ nodes.length }}</div>
- </div>
- <div
- class="bg-gray-50/50 dark:bg-zinc-800/50 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
- >
- <div
- class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-1"
- >
- Links
- </div>
- <div class="text-lg font-bold text-emerald-600 dark:text-emerald-400">
- {{ edges.length }}
- </div>
- </div>
- </div>
-
- <div
- class="bg-zinc-950/5 dark:bg-white/5 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
- >
- <div
- class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-2"
- >
- Interfaces
- </div>
- <div class="flex items-center gap-4">
- <div class="flex items-center gap-1.5">
- <div class="w-2 h-2 rounded-full bg-emerald-500"></div>
- <span class="text-xs font-bold text-gray-700 dark:text-zinc-300"
- >{{ onlineInterfaces.length }} Online</span
- >
- </div>
- <div class="flex items-center gap-1.5">
- <div class="w-2 h-2 rounded-full bg-red-500"></div>
- <span class="text-xs font-bold text-gray-700 dark:text-zinc-300"
- >{{ offlineInterfaces.length }} Offline</span
- >
- </div>
- </div>
- </div>
- </div>
- </div>
-
- <!-- search box -->
- <div class="sm:ml-auto w-full sm:w-auto pointer-events-auto">
- <div class="relative group">
- <div
- class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors"
- >
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
- <path
- fill-rule="evenodd"
- d="M9 3.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM2.25 10a7.75 7.75 0 1 1 14.03 4.5l3.47 3.47a.75.75 0 0 1-1.06 1.06l-3.47-3.47A7.75 7.75 0 0 1 2.25 10Z"
- clip-rule="evenodd"
- />
- </svg>
- </div>
- <input
- v-model="searchQuery"
- type="text"
- :placeholder="`Search nodes (${nodes.length})...`"
- class="block w-full sm:w-64 pl-9 pr-10 py-2.5 sm:py-3 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl border border-gray-200/50 dark:border-zinc-800/50 rounded-2xl text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-blue-500/50 sm:focus:w-80 md:max-lg:focus:w-72 lg:focus:w-80 transition-all dark:text-zinc-100 shadow-sm"
- />
- <button
- v-if="searchQuery"
- class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-zinc-200 transition-colors"
- @click="searchQuery = ''"
- >
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
- <path
- d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
- />
- </svg>
- </button>
- </div>
- </div>
- </div>
-
- <!-- navigation breadcrumb style legend -->
- <div
- class="absolute bottom-4 right-4 z-10 hidden sm:flex items-center gap-2 px-4 py-2 rounded-full border border-gray-200/50 dark:border-zinc-800/50 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl"
- >
- <div class="flex items-center gap-1.5">
- <div class="w-3 h-3 rounded-full border-2 border-emerald-500 bg-emerald-500/20"></div>
- <span class="text-[10px] font-bold text-gray-600 dark:text-zinc-400 uppercase">Direct</span>
- </div>
- <div class="w-px h-3 bg-gray-200 dark:bg-zinc-800 mx-1"></div>
- <div class="flex items-center gap-1.5">
- <div class="w-3 h-3 rounded-full border-2 border-blue-500/50 bg-blue-500/10"></div>
- <span class="text-[10px] font-bold text-gray-600 dark:text-zinc-400 uppercase">Multi-Hop</span>
- </div>
- <div v-if="discoveredInterfaces.length > 0" class="w-px h-3 bg-gray-200 dark:bg-zinc-800 mx-1"></div>
- <div v-if="discoveredInterfaces.length > 0" class="flex items-center gap-1.5">
- <div class="w-3 h-3 rounded-full border-2 border-cyan-500/50 bg-cyan-500/10"></div>
- <span class="text-[10px] font-bold text-gray-600 dark:text-zinc-400 uppercase"
- >Discovered ({{ discoveredInterfaces.length }})</span
- >
- </div>
- </div>
+ <NetworkVisualiserLoadingOverlay
+ :is-loading="isLoading"
+ :loading-status="loadingStatus"
+ :total-nodes-to-load="totalNodesToLoad"
+ :loaded-nodes-count="loadedNodesCount"
+ :current-batch="currentBatch"
+ :total-batches="totalBatches"
+ />
+
+ <NetworkVisualiserToolbar
+ :is-showing-controls="isShowingControls"
+ :is-updating="isUpdating"
+ :is-loading="isLoading"
+ :auto-reload="autoReload"
+ :enable-physics="enablePhysics"
+ :hop-filter-slider="hopFilterSlider"
+ :hop-slider-max="hopSliderMax"
+ :node-count="nodes.length"
+ :edge-count="edges.length"
+ :online-interface-count="onlineInterfaces.length"
+ :offline-interface-count="offlineInterfaces.length"
+ :search-query="searchQuery"
+ @update:is-showing-controls="isShowingControls = $event"
+ @update:auto-reload="autoReload = $event"
+ @update:enable-physics="enablePhysics = $event"
+ @update:hop-filter-slider="hopFilterSlider = $event"
+ @update:search-query="searchQuery = $event"
+ @manual-update="manualUpdate"
+ />
+ <NetworkVisualiserLegend
+ :show-discovered-interfaces="showDiscoveredInterfaces"
+ :discovered-count="discoveredInterfaces.length"
+ />
</div>
</template>
@@ -294,12 +46,18 @@ import { DataSet } from "vis-data";
import * as mdi from "@mdi/js";
import Utils from "../../js/Utils";
import GlobalEmitter from "../../js/GlobalEmitter";
-import Toggle from "../forms/Toggle.vue";
+import ToastUtils from "../../js/ToastUtils";
+import { PONG_NODE_IDS } from "./internal/visualiserConstants.js";
+import NetworkVisualiserLoadingOverlay from "./internal/NetworkVisualiserLoadingOverlay.vue";
+import NetworkVisualiserToolbar from "./internal/NetworkVisualiserToolbar.vue";
+import NetworkVisualiserLegend from "./internal/NetworkVisualiserLegend.vue";
export default {
name: "NetworkVisualiser",
components: {
- Toggle,
+ NetworkVisualiserLoadingOverlay,
+ NetworkVisualiserToolbar,
+ NetworkVisualiserLegend,
},
data() {
return {
@@ -313,8 +71,16 @@ export default {
enablePhysics: true,
enableOrbit: false,
enableBouncingBalls: false,
+ enableFallingSkies: false,
+ enableSnake: false,
+ enablePong: false,
orbitAnimationFrame: null,
bouncingBallsAnimationFrame: null,
+ fallingSkiesAnimationFrame: null,
+ snakeAnimationFrame: null,
+ pongAnimationFrame: null,
+ showDisabledInterfaces: false,
+ showDiscoveredInterfaces: false,
loadingStatus: "Initializing...",
loadedNodesCount: 0,
totalNodesToLoad: 0,
@@ -339,6 +105,9 @@ export default {
hopFilterDebounceTimer: null,
abortController: new AbortController(),
currentLOD: "high",
+ lastVizKeys: [],
+ vizHadOneLayout: false,
+ didDisableStabilization: false,
};
},
computed: {
@@ -366,14 +135,18 @@ export default {
this.manualUpdate();
}
},
- enablePhysics(val) {
- if (this.network) {
- this.network.setOptions({ physics: { enabled: val && !this.enableOrbit } });
- }
+ enablePhysics() {
+ this.refreshPhysicsEnabled();
},
enableOrbit(val) {
if (val) {
this.enableBouncingBalls = false;
+ this.enableFallingSkies = false;
+ this.enableSnake = false;
+ this.enablePong = false;
+ this.stopFallingSkies();
+ this.stopSnake();
+ this.stopPong();
this.startOrbit();
} else {
this.stopOrbit();
@@ -382,11 +155,62 @@ export default {
enableBouncingBalls(val) {
if (val) {
this.enableOrbit = false;
+ this.enableFallingSkies = false;
+ this.enableSnake = false;
+ this.enablePong = false;
+ this.stopFallingSkies();
+ this.stopSnake();
+ this.stopPong();
this.startBouncingBalls();
} else {
this.stopBouncingBalls();
}
},
+ enableFallingSkies(val) {
+ if (val) {
+ this.enableOrbit = false;
+ this.enableBouncingBalls = false;
+ this.enableSnake = false;
+ this.enablePong = false;
+ this.stopOrbit();
+ this.stopBouncingBalls();
+ this.stopSnake();
+ this.stopPong();
+ this.startFallingSkies();
+ } else {
+ this.stopFallingSkies();
+ }
+ },
+ enableSnake(val) {
+ if (val) {
+ this.enableOrbit = false;
+ this.enableBouncingBalls = false;
+ this.enableFallingSkies = false;
+ this.enablePong = false;
+ this.stopOrbit();
+ this.stopBouncingBalls();
+ this.stopFallingSkies();
+ this.stopPong();
+ this.startSnake();
+ } else {
+ this.stopSnake();
+ }
+ },
+ enablePong(val) {
+ if (val) {
+ this.enableOrbit = false;
+ this.enableBouncingBalls = false;
+ this.enableFallingSkies = false;
+ this.enableSnake = false;
+ this.stopOrbit();
+ this.stopBouncingBalls();
+ this.stopFallingSkies();
+ this.stopSnake();
+ this.startPong();
+ } else {
+ this.stopPong();
+ }
+ },
searchQuery() {
// we don't want to trigger a full update from server, just re-run the filtering on existing data
this.processVisualization();
@@ -414,8 +238,24 @@ export default {
if (this._toggleBouncingBallsHandler) {
GlobalEmitter.off("toggle-bouncing-balls", this._toggleBouncingBallsHandler);
}
+ if (this._toggleFallingSkiesHandler) {
+ GlobalEmitter.off("toggle-falling-skies", this._toggleFallingSkiesHandler);
+ }
+ if (this._visualiserPrefsHandler) {
+ GlobalEmitter.off("visualiser-display-prefs-changed", this._visualiserPrefsHandler);
+ }
+ if (this._toggleSnakeHandler) {
+ GlobalEmitter.off("toggle-snake", this._toggleSnakeHandler);
+ }
+ if (this._togglePongHandler) {
+ GlobalEmitter.off("toggle-pong", this._togglePongHandler);
+ }
+ this.detachGameKeyListeners();
this.stopOrbit();
this.stopBouncingBalls();
+ this.stopFallingSkies();
+ this.stopSnake(false);
+ this.stopPong(false);
clearInterval(this.reloadInterval);
if (this.hopFilterDebounceTimer) {
clearTimeout(this.hopFilterDebounceTimer);
@@ -453,6 +293,30 @@ export default {
};
GlobalEmitter.on("toggle-bouncing-balls", this._toggleBouncingBallsHandler);
+ this._toggleFallingSkiesHandler = () => {
+ this.enableFallingSkies = !this.enableFallingSkies;
+ };
+ GlobalEmitter.on("toggle-falling-skies", this._toggleFallingSkiesHandler);
+
+ this._visualiserPrefsHandler = () => {
+ this.loadVisualiserDisplayPrefs();
+ if (this.network) {
+ this.processVisualization();
+ }
+ };
+ GlobalEmitter.on("visualiser-display-prefs-changed", this._visualiserPrefsHandler);
+
+ this._toggleSnakeHandler = () => {
+ this.enableSnake = !this.enableSnake;
+ };
+ GlobalEmitter.on("toggle-snake", this._toggleSnakeHandler);
+
+ this._togglePongHandler = () => {
+ this.enablePong = !this.enablePong;
+ };
+ GlobalEmitter.on("toggle-pong", this._togglePongHandler);
+
+ this.loadVisualiserDisplayPrefs();
this.init();
},
methods: {
@@ -710,73 +574,163 @@ export default {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${foregroundColor}" d="${iconPath}"/></svg>`;
},
+ loadVisualiserDisplayPrefs() {
+ try {
+ if (typeof localStorage !== "undefined") {
+ if (localStorage.getItem("meshchatx.visualiser.showDisabledInterfaces") === "true") {
+ this.showDisabledInterfaces = true;
+ }
+ if (localStorage.getItem("meshchatx.visualiser.showDiscoveredInterfaces") === "true") {
+ this.showDiscoveredInterfaces = true;
+ }
+ }
+ } catch {
+ /* localStorage unavailable */
+ }
+ },
+ refreshPhysicsEnabled() {
+ if (!this.network) return;
+ const on =
+ this.enablePhysics &&
+ !this.enableOrbit &&
+ !this.enableBouncingBalls &&
+ !this.enableFallingSkies &&
+ !this.enableSnake &&
+ !this.enablePong;
+ this.network.setOptions({ physics: { enabled: on } });
+ },
+ pickStablePosition(id, posById, initialFn) {
+ const prev = posById[id];
+ if (prev && Number.isFinite(prev.x) && Number.isFinite(prev.y)) {
+ return { x: prev.x, y: prev.y };
+ }
+ const v = initialFn();
+ posById[id] = { x: v.x, y: v.y };
+ return v;
+ },
+ edgeHiddenForMode(peerNodeId) {
+ if (this.enableBouncingBalls || this.enableSnake || this.enablePong) {
+ return true;
+ }
+ if (
+ peerNodeId != null &&
+ this.enableFallingSkies &&
+ this._fallingPendingIds &&
+ this._fallingPendingIds.has(peerNodeId)
+ ) {
+ return true;
+ }
+ return false;
+ },
+ edgesHiddenForOverlayGames() {
+ return this.enableBouncingBalls || this.enableSnake || this.enablePong;
+ },
startOrbit() {
if (!this.network) return;
this.stopOrbit();
this.stopBouncingBalls();
+ this.stopSnake(false);
+ this.stopPong(false);
- // Disable physics while orbiting
- this.network.setOptions({ physics: { enabled: false } });
-
- // Hide edges
- const edges = this.edges.get();
- const updatedEdges = edges.map((edge) => ({ id: edge.id, hidden: true }));
- this.edges.update(updatedEdges);
+ this.refreshPhysicsEnabled();
- // Get current positions of nodes to start orbit from where they are
const nodeIds = this.nodes.getIds();
- const positions = this.network.getPositions(nodeIds);
+ const positions = this.network.getPositions(nodeIds) || {};
const mePos = positions["me"] || { x: 0, y: 0 };
- this._orbitNodes = nodeIds
- .filter((id) => id !== "me")
- .map((id) => {
- const pos = positions[id] || {
- x: Math.random() * 1000 - 500,
- y: Math.random() * 1000 - 500,
- };
+ this._orbitAroundMe = [];
+ this._orbitAroundIface = [];
+
+ for (const id of nodeIds) {
+ if (id === "me") continue;
+ const n = this.nodes.get(id);
+ if (!n || !n.group) continue;
+ const pos = positions[id] || { x: mePos.x, y: mePos.y };
+ if (n.group === "interface" || n.group === "discovered") {
const dx = pos.x - mePos.x;
const dy = pos.y - mePos.y;
- const radius = Math.sqrt(dx * dx + dy * dy) || Math.random() * 500 + 100;
- return {
- id: id,
- radius: radius,
+ const r = Math.hypot(dx, dy) || 400;
+ this._orbitAroundMe.push({
+ id,
+ radius: r,
angle: Math.atan2(dy, dx),
- // Random speed based on radius - further nodes move slower usually, but let's make it more dynamic
- speed: (0.002 + Math.random() * 0.005) * (Math.random() > 0.5 ? 1 : -1),
- };
- });
+ speed: (0.0012 + Math.random() * 0.0024) * (Math.random() > 0.5 ? 1 : -1),
+ });
+ } else if (n.group === "announce" && n._parentInterface) {
+ const parentId = n._parentInterface;
+ const pPos = positions[parentId] || mePos;
+ const dx = pos.x - pPos.x;
+ const dy = pos.y - pPos.y;
+ const r = Math.hypot(dx, dy) || 140;
+ this._orbitAroundIface.push({
+ id,
+ parentId,
+ radius: r,
+ angle: Math.atan2(dy, dx),
+ speed: (0.002 + Math.random() * 0.004) * (Math.random() > 0.5 ? 1 : -1),
+ });
+ }
+ }
const animate = () => {
if (!this.enableOrbit) return;
- // Get current position of 'me' node in case it was dragged
- const positions = this.network.getPositions(["me"]);
- const mePos = positions["me"] || { x: 0, y: 0 };
+ const meP = this.network.getPositions(["me"])["me"] || { x: 0, y: 0 };
+
+ const ifacePos = { me: meP };
+ const batch = [];
- const updates = this._orbitNodes.map((data) => {
+ for (const data of this._orbitAroundMe) {
if (data.id === this._draggingNodeId) {
- // If dragging, update our internal radius/angle to match new position
- const nodePositions = this.network.getPositions([data.id]);
- const pos = nodePositions[data.id];
- if (pos) {
- const dx = pos.x - mePos.x;
- const dy = pos.y - mePos.y;
- data.radius = Math.sqrt(dx * dx + dy * dy);
+ const p = this.network.getPositions([data.id])[data.id];
+ if (p) {
+ const dx = p.x - meP.x;
+ const dy = p.y - meP.y;
+ data.radius = Math.hypot(dx, dy) || data.radius;
data.angle = Math.atan2(dy, dx);
+ ifacePos[data.id] = p;
}
- return { id: data.id, x: pos.x, y: pos.y };
+ continue;
}
+ data.angle += data.speed;
+ const x = meP.x + Math.cos(data.angle) * data.radius;
+ const y = meP.y + Math.sin(data.angle) * data.radius;
+ batch.push({ id: data.id, x, y });
+ ifacePos[data.id] = { x, y };
+ }
+ if (batch.length > 0) {
+ this.nodes.update(batch);
+ }
+
+ const annBatch = [];
+ for (const data of this._orbitAroundIface) {
+ if (data.id === this._draggingNodeId) {
+ const p = this.network.getPositions([data.id])[data.id];
+ const parent =
+ ifacePos[data.parentId] || this.network.getPositions([data.parentId])[data.parentId];
+ if (p && parent) {
+ const dx = p.x - parent.x;
+ const dy = p.y - parent.y;
+ data.radius = Math.hypot(dx, dy) || data.radius;
+ data.angle = Math.atan2(dy, dx);
+ }
+ continue;
+ }
+ const parent =
+ ifacePos[data.parentId] || this.network.getPositions([data.parentId])[data.parentId] || meP;
data.angle += data.speed;
- return {
+ annBatch.push({
id: data.id,
- x: mePos.x + Math.cos(data.angle) * data.radius,
- y: mePos.y + Math.sin(data.angle) * data.radius,
- };
- });
+ x: parent.x + Math.cos(data.angle) * data.radius,
+ y: parent.y + Math.sin(data.angle) * data.radius,
+ });
+ }
+
+ if (annBatch.length > 0) {
+ this.nodes.update(annBatch);
+ }
- this.nodes.update(updates);
this.orbitAnimationFrame = requestAnimationFrame(animate);
};
@@ -787,21 +741,78 @@ export default {
cancelAnimationFrame(this.orbitAnimationFrame);
this.orbitAnimationFrame = null;
}
-
- // Restore edges visibility
- const edges = this.edges.get();
- const updatedEdges = edges.map((edge) => ({ id: edge.id, hidden: false }));
- this.edges.update(updatedEdges);
-
- // Re-enable physics if it was enabled
+ this._orbitAroundMe = [];
+ this._orbitAroundIface = [];
+ this.refreshPhysicsEnabled();
+ },
+ startFallingSkies() {
+ if (!this.network) return;
+ this.stopSnake(false);
+ this.stopPong(false);
+ this.refreshPhysicsEnabled();
+ if (this._fallingById && this._fallingById.size > 0) {
+ this.scheduleFallingTick();
+ }
+ },
+ stopFallingSkies() {
+ if (this.fallingSkiesAnimationFrame) {
+ cancelAnimationFrame(this.fallingSkiesAnimationFrame);
+ this.fallingSkiesAnimationFrame = null;
+ }
+ this._fallingById = new Map();
+ this._fallingPendingIds = new Set();
+ this.refreshPhysicsEnabled();
if (this.network) {
- this.network.setOptions({ physics: { enabled: this.enablePhysics } });
+ this.processVisualization();
}
},
+ scheduleFallingTick() {
+ if (!this.enableFallingSkies || !this.network) return;
+ if (this.fallingSkiesAnimationFrame != null) return;
+ const tick = () => {
+ this.fallingSkiesAnimationFrame = null;
+ if (!this.enableFallingSkies || !this._fallingById || this._fallingById.size === 0) {
+ return;
+ }
+ const gravity = 0.55;
+ const updates = [];
+ const done = [];
+
+ for (const [id, st] of this._fallingById) {
+ st.vy += gravity;
+ st.y += st.vy;
+ if (st.y >= st.ty - 2) {
+ updates.push({ id, x: st.tx, y: st.ty });
+ done.push({ id, edgeIds: st.edgeIds || [] });
+ } else {
+ updates.push({ id, x: st.tx, y: st.y });
+ }
+ }
+
+ if (updates.length > 0) {
+ this.nodes.update(updates);
+ }
+ const edgeUnhide = [];
+ for (const { id, edgeIds } of done) {
+ this._fallingById.delete(id);
+ this._fallingPendingIds.delete(id);
+ edgeUnhide.push(...edgeIds);
+ }
+ if (edgeUnhide.length > 0) {
+ this.edges.update(edgeUnhide.map((eid) => ({ id: eid, hidden: this.enableBouncingBalls })));
+ }
+ if (this._fallingById.size > 0) {
+ this.fallingSkiesAnimationFrame = requestAnimationFrame(tick);
+ }
+ };
+ this.fallingSkiesAnimationFrame = requestAnimationFrame(tick);
+ },
startBouncingBalls() {
if (!this.network) return;
this.stopBouncingBalls();
this.stopOrbit();
+ this.stopSnake(false);
+ this.stopPong(false);
// Disable physics
this.network.setOptions({ physics: { enabled: false } });
@@ -909,14 +920,469 @@ export default {
this.bouncingBallsAnimationFrame = null;
}
- // Restore edges visibility
const edges = this.edges.get();
const updatedEdges = edges.map((edge) => ({ id: edge.id, hidden: false }));
this.edges.update(updatedEdges);
- // Re-enable physics if it was enabled
- if (this.network) {
- this.network.setOptions({ physics: { enabled: this.enablePhysics } });
+ this.refreshPhysicsEnabled();
+ },
+ getViewCanvasBounds() {
+ const container = document.getElementById("network");
+ if (!container || !this.network) return null;
+ const scale = this.network.getScale();
+ const vp = this.network.getViewPosition();
+ const w = container.clientWidth;
+ const h = container.clientHeight;
+ const halfW = w / (2 * scale);
+ const halfH = h / (2 * scale);
+ return {
+ left: vp.x - halfW,
+ right: vp.x + halfW,
+ top: vp.y - halfH,
+ bottom: vp.y + halfH,
+ scale,
+ };
+ },
+ attachGameKeyListeners(mode) {
+ this.detachGameKeyListeners();
+ this._gameKeyMode = mode;
+ this._snakeKeys = { u: 0, d: 0, l: 0, r: 0, w: 0, s: 0 };
+ this._onGameKeyDown = (e) => {
+ const tag = (e.target && e.target.tagName) || "";
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
+ if (mode === "snake") {
+ if (["ArrowUp", "w", "W"].includes(e.key)) {
+ e.preventDefault();
+ this._snakeKeys.u = 1;
+ }
+ if (["ArrowDown", "s", "S"].includes(e.key)) {
+ e.preventDefault();
+ this._snakeKeys.d = 1;
+ }
+ if (["ArrowLeft", "a", "A"].includes(e.key)) {
+ e.preventDefault();
+ this._snakeKeys.l = 1;
+ }
+ if (["ArrowRight", "d", "D"].includes(e.key)) {
+ e.preventDefault();
+ this._snakeKeys.r = 1;
+ }
+ } else if (mode === "pong") {
+ if (e.key === "w" || e.key === "W") {
+ e.preventDefault();
+ this._snakeKeys.w = 1;
+ }
+ if (e.key === "s" || e.key === "S") {
+ e.preventDefault();
+ this._snakeKeys.s = 1;
+ }
+ }
+ };
+ this._onGameKeyUp =
+ mode === "snake"
+ ? (e) => {
+ if (["ArrowUp", "w", "W"].includes(e.key)) this._snakeKeys.u = 0;
+ if (["ArrowDown", "s", "S"].includes(e.key)) this._snakeKeys.d = 0;
+ if (["ArrowLeft", "a", "A"].includes(e.key)) this._snakeKeys.l = 0;
+ if (["ArrowRight", "d", "D"].includes(e.key)) this._snakeKeys.r = 0;
+ }
+ : (e) => {
+ if (e.key === "w" || e.key === "W") this._snakeKeys.w = 0;
+ if (e.key === "s" || e.key === "S") this._snakeKeys.s = 0;
+ };
+ window.addEventListener("keydown", this._onGameKeyDown, true);
+ window.addEventListener("keyup", this._onGameKeyUp, true);
+ },
+ detachGameKeyListeners() {
+ if (this._onGameKeyDown) {
+ window.removeEventListener("keydown", this._onGameKeyDown, true);
+ this._onGameKeyDown = null;
+ }
+ if (this._onGameKeyUp) {
+ window.removeEventListener("keyup", this._onGameKeyUp, true);
+ this._onGameKeyUp = null;
+ }
+ this._gameKeyMode = null;
+ },
+ getPositionAlongTrail(trail, distBehind) {
+ if (!trail || trail.length === 0) return { x: 0, y: 0 };
+ if (trail.length === 1) return { ...trail[0] };
+ let d = 0;
+ for (let i = trail.length - 1; i > 0; i--) {
+ const p = trail[i];
+ const q = trail[i - 1];
+ const seg = Math.hypot(p.x - q.x, p.y - q.y);
+ if (d + seg >= distBehind) {
+ const t = seg > 0 ? (distBehind - d) / seg : 0;
+ return { x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t };
+ }
+ d += seg;
+ }
+ return { ...trail[0] };
+ },
+ startSnake() {
+ if (!this.network) return;
+ this.stopSnake(false);
+ this.stopPong(false);
+ this.stopOrbit();
+ this.stopBouncingBalls();
+ this.stopFallingSkies();
+ this.network.setOptions({ physics: { enabled: false } });
+ const edges = this.edges.get();
+ this.edges.update(edges.map((edge) => ({ id: edge.id, hidden: true })));
+
+ const me = this.network.getPositions(["me"]).me || { x: 0, y: 0 };
+ this._snakeVx = 7;
+ this._snakeVy = 0;
+ this._snakeHeadX = me.x;
+ this._snakeHeadY = me.y;
+ this._snakeTrail = [{ x: me.x, y: me.y }];
+ this._snakeEatenIds = [];
+ this._snakeFoodIds = new Set();
+ for (const id of this.nodes.getIds()) {
+ if (id !== "me") this._snakeFoodIds.add(id);
+ }
+ this.attachGameKeyListeners("snake");
+ ToastUtils.info(this.$t("visualiser.snake_hint"));
+
+ const speed = 8;
+ const margin = 40;
+ const headR = 28;
+ const tailGap = 42;
+
+ const tick = () => {
+ if (!this.enableSnake || !this.network) return;
+ const b = this.getViewCanvasBounds();
+ if (!b) return;
+
+ let vx = 0;
+ let vy = 0;
+ const k = this._snakeKeys || {};
+ if (k.u) vy -= 1;
+ if (k.d) vy += 1;
+ if (k.l) vx -= 1;
+ if (k.r) vx += 1;
+ if (vx !== 0 || vy !== 0) {
+ const len = Math.hypot(vx, vy) || 1;
+ this._snakeVx = (vx / len) * speed;
+ this._snakeVy = (vy / len) * speed;
+ }
+
+ let hx = this._snakeHeadX + this._snakeVx;
+ let hy = this._snakeHeadY + this._snakeVy;
+ hx = Math.max(b.left + margin, Math.min(b.right - margin, hx));
+ hy = Math.max(b.top + margin, Math.min(b.bottom - margin, hy));
+ this._snakeHeadX = hx;
+ this._snakeHeadY = hy;
+
+ this.nodes.update([{ id: "me", x: hx, y: hy }]);
+
+ const last = this._snakeTrail[this._snakeTrail.length - 1];
+ if (!last || Math.hypot(hx - last.x, hy - last.y) > 3) {
+ this._snakeTrail.push({ x: hx, y: hy });
+ if (this._snakeTrail.length > 8000) {
+ this._snakeTrail.splice(0, 1500);
+ }
+ }
+
+ const updates = [];
+ for (let i = 0; i < this._snakeEatenIds.length; i++) {
+ const id = this._snakeEatenIds[i];
+ const pos = this.getPositionAlongTrail(this._snakeTrail, (i + 1) * tailGap);
+ updates.push({ id, x: pos.x, y: pos.y });
+ }
+ if (updates.length > 0) this.nodes.update(updates);
+
+ const foodArr = [...this._snakeFoodIds];
+ const posMap = foodArr.length > 0 ? this.network.getPositions(foodArr) : {};
+ for (const fid of foodArr) {
+ const fp = posMap[fid];
+ if (!fp) {
+ this._snakeFoodIds.delete(fid);
+ continue;
+ }
+ const n = this.nodes.get(fid);
+ const nr = n && n.size ? n.size * 0.45 : 14;
+ if (Math.hypot(hx - fp.x, hy - fp.y) < headR + nr) {
+ this._snakeFoodIds.delete(fid);
+ this._snakeEatenIds.push(fid);
+ }
+ }
+
+ for (let i = 0; i < this._snakeEatenIds.length; i++) {
+ const pos = this.getPositionAlongTrail(this._snakeTrail, (i + 1) * tailGap);
+ if (Math.hypot(hx - pos.x, hy - pos.y) < headR * 0.55) {
+ if (this.snakeAnimationFrame) {
+ cancelAnimationFrame(this.snakeAnimationFrame);
+ this.snakeAnimationFrame = null;
+ }
+ ToastUtils.info(this.$t("visualiser.snake_hit_self"));
+ this.enableSnake = false;
+ return;
+ }
+ }
+
+ if (this._snakeFoodIds.size === 0 && this._snakeEatenIds.length > 0) {
+ if (this.snakeAnimationFrame) {
+ cancelAnimationFrame(this.snakeAnimationFrame);
+ this.snakeAnimationFrame = null;
+ }
+ ToastUtils.success(this.$t("visualiser.snake_win"));
+ this.enableSnake = false;
+ return;
+ }
+
+ this.snakeAnimationFrame = requestAnimationFrame(tick);
+ };
+
+ this.snakeAnimationFrame = requestAnimationFrame(tick);
+ },
+ stopSnake(runProcessViz = true) {
+ if (this.snakeAnimationFrame) {
+ cancelAnimationFrame(this.snakeAnimationFrame);
+ this.snakeAnimationFrame = null;
+ }
+ this.detachGameKeyListeners();
+ this._snakeTrail = [];
+ this._snakeFoodIds = null;
+ this._snakeEatenIds = [];
+ const edges = this.edges.get();
+ this.edges.update(edges.map((edge) => ({ id: edge.id, hidden: false })));
+ this.refreshPhysicsEnabled();
+ if (runProcessViz && this.network) {
+ this.processVisualization();
+ }
+ },
+ startPong() {
+ if (!this.network) return;
+ this.stopPong(false);
+ this.stopSnake(false);
+ this.stopOrbit();
+ this.stopBouncingBalls();
+ this.stopFallingSkies();
+ this.network.setOptions({ physics: { enabled: false } });
+ const edges = this.edges.get();
+ this.edges.update(edges.map((edge) => ({ id: edge.id, hidden: true })));
+
+ const b = this.getViewCanvasBounds();
+ if (!b) return;
+ const midX = (b.left + b.right) / 2;
+ const midY = (b.top + b.bottom) / 2;
+ const padH = 100;
+ const padW = 14;
+ const ballR = 10;
+
+ this._pongBall = { x: midX, y: midY, vx: 9, vy: 6, r: ballR };
+ this._pongPadL = { x: b.left + 36, y: midY, w: padW, h: padH };
+ this._pongPadR = { x: b.right - 36, y: midY, w: padW, h: padH };
+ this._pongScoreYou = 0;
+ this._pongScoreAi = 0;
+ this._pongWinPoints = 7;
+
+ const isDark = document.documentElement.classList.contains("dark");
+ const padBg = isDark ? "#1e40af" : "#60a5fa";
+ const padBr = isDark ? "#3b82f6" : "#2563eb";
+ const hudFg = isDark ? "#fafafa" : "#18181b";
+ const hudBg = isDark ? "#27272a" : "#f4f4f5";
+
+ this.nodes.update([
+ {
+ id: "__pong_ball",
+ group: "pong",
+ shape: "dot",
+ size: ballR * 2,
+ color: this.nodeColor("#e2e8f0", isDark ? "#f8fafc" : "#0f172a"),
+ label: "",
+ font: { size: 0 },
+ x: this._pongBall.x,
+ y: this._pongBall.y,
+ physics: false,
+ },
+ {
+ id: "__pong_hud",
+ group: "pong",
+ shape: "box",
+ label: `0 - 0`,
+ font: { size: 16, color: hudFg, bold: true },
+ margin: 10,
+ color: {
+ background: hudBg,
+ border: padBr,
+ highlight: { background: hudBg, border: padBr },
+ hover: { background: hudBg, border: padBr },
+ },
+ x: midX,
+ y: b.top + 44,
+ physics: false,
+ },
+ {
+ id: "__pong_pad_l",
+ group: "pong",
+ shape: "box",
+ label: "",
+ font: { size: 0 },
+ margin: 6,
+ widthConstraint: { minimum: padW * 2, maximum: padW * 2 },
+ heightConstraint: { minimum: padH, maximum: padH },
+ color: { background: padBg, border: padBr, highlight: { background: padBg, border: padBr } },
+ x: this._pongPadL.x,
+ y: this._pongPadL.y,
+ physics: false,
+ },
+ {
+ id: "__pong_pad_r",
+ group: "pong",
+ shape: "box",
+ label: "",
+ font: { size: 0 },
+ margin: 6,
+ widthConstraint: { minimum: padW * 2, maximum: padW * 2 },
+ heightConstraint: { minimum: padH, maximum: padH },
+ color: {
+ background: isDark ? "#4c1d95" : "#a78bfa",
+ border: padBr,
+ highlight: { background: padBg, border: padBr },
+ },
+ x: this._pongPadR.x,
+ y: this._pongPadR.y,
+ physics: false,
+ },
+ ]);
+
+ this.attachGameKeyListeners("pong");
+ ToastUtils.info(this.$t("visualiser.pong_hint"));
+
+ const paddleSpeed = 11;
+ const aiMaxStep = paddleSpeed * 0.92;
+
+ const resetBall = (bounds, towardSign) => {
+ const bb = this._pongBall;
+ bb.x = (bounds.left + bounds.right) / 2;
+ bb.y = (bounds.top + bounds.bottom) / 2;
+ const ramp = Math.min(5, this._pongScoreYou + this._pongScoreAi);
+ const base = 8.5 + ramp * 0.35;
+ bb.vx = towardSign * base * (0.95 + Math.random() * 0.1);
+ bb.vy = (Math.random() * 8 + 3) * (Math.random() > 0.5 ? 1 : -1);
+ };
+
+ const loop = () => {
+ if (!this.enablePong || !this.network || !this._pongBall) return;
+ const bounds = this.getViewCanvasBounds();
+ if (!bounds) return;
+ const ball = this._pongBall;
+ const pl = this._pongPadL;
+ const pr = this._pongPadR;
+
+ if (this._snakeKeys.w) pl.y -= paddleSpeed;
+ if (this._snakeKeys.s) pl.y += paddleSpeed;
+
+ const ph = padH / 2;
+ const dy = ball.y - pr.y;
+ const step = Math.min(aiMaxStep, Math.abs(dy) * 0.22);
+ if (dy < -1.5) pr.y -= step;
+ else if (dy > 1.5) pr.y += step;
+
+ pl.y = Math.max(bounds.top + ph + 8, Math.min(bounds.bottom - ph - 8, pl.y));
+ pr.y = Math.max(bounds.top + ph + 8, Math.min(bounds.bottom - ph - 8, pr.y));
+
+ ball.x += ball.vx;
+ ball.y += ball.vy;
+
+ if (ball.y - ball.r < bounds.top) {
+ ball.y = bounds.top + ball.r;
+ ball.vy *= -1;
+ } else if (ball.y + ball.r > bounds.bottom) {
+ ball.y = bounds.bottom - ball.r;
+ ball.vy *= -1;
+ }
+
+ if (ball.vx < 0 && ball.x - ball.r <= pl.x + padW && ball.y >= pl.y - ph && ball.y <= pl.y + ph) {
+ ball.x = pl.x + padW + ball.r;
+ ball.vx *= -1.025;
+ ball.vy += (Math.random() - 0.5) * 2.2;
+ } else if (
+ ball.vx > 0 &&
+ ball.x + ball.r >= pr.x - padW &&
+ ball.y >= pr.y - ph &&
+ ball.y <= pr.y + ph
+ ) {
+ ball.x = pr.x - padW - ball.r;
+ ball.vx *= -1.025;
+ ball.vy += (Math.random() - 0.5) * 2.2;
+ }
+
+ const edgeMargin = 18;
+ if (ball.x - ball.r < bounds.left - edgeMargin) {
+ this._pongScoreAi++;
+ if (this._pongScoreAi >= this._pongWinPoints) {
+ if (this.pongAnimationFrame) {
+ cancelAnimationFrame(this.pongAnimationFrame);
+ this.pongAnimationFrame = null;
+ }
+ ToastUtils.info(this.$t("visualiser.pong_win_ai"));
+ this.enablePong = false;
+ return;
+ }
+ resetBall(bounds, -1);
+ } else if (ball.x + ball.r > bounds.right + edgeMargin) {
+ this._pongScoreYou++;
+ if (this._pongScoreYou >= this._pongWinPoints) {
+ if (this.pongAnimationFrame) {
+ cancelAnimationFrame(this.pongAnimationFrame);
+ this.pongAnimationFrame = null;
+ }
+ ToastUtils.success(this.$t("visualiser.pong_win_you"));
+ this.enablePong = false;
+ return;
+ }
+ resetBall(bounds, 1);
+ }
+
+ const hudLabel = `${this._pongScoreYou} - ${this._pongScoreAi}`;
+
+ this.nodes.update([
+ { id: "__pong_ball", x: ball.x, y: ball.y },
+ { id: "__pong_pad_l", x: pl.x, y: pl.y },
+ { id: "__pong_pad_r", x: pr.x, y: pr.y },
+ { id: "__pong_hud", label: hudLabel, x: (bounds.left + bounds.right) / 2, y: bounds.top + 44 },
+ ]);
+
+ this.pongAnimationFrame = requestAnimationFrame(loop);
+ };
+ this.pongAnimationFrame = requestAnimationFrame(loop);
+ },
+ stopPong(runProcessViz = true) {
+ if (this.pongAnimationFrame) {
+ cancelAnimationFrame(this.pongAnimationFrame);
+ this.pongAnimationFrame = null;
+ }
+ this.detachGameKeyListeners();
+ this._pongBall = null;
+ for (const id of PONG_NODE_IDS) {
+ try {
+ this.nodes.remove(id);
+ } catch {
+ /* node may already be removed */
+ }
+ }
+ const edges = this.edges.get();
+ this.edges.update(edges.map((edge) => ({ id: edge.id, hidden: false })));
+ this.refreshPhysicsEnabled();
+ if (runProcessViz && this.network) {
+ this.processVisualization();
+ }
+ },
+ reconcileSnakeFoodAfterViz() {
+ if (!this._snakeFoodIds || !this._snakeEatenIds) return;
+ const ids = new Set(this.nodes.getIds());
+ this._snakeEatenIds = this._snakeEatenIds.filter((id) => ids.has(id));
+ const eaten = new Set(this._snakeEatenIds);
+ for (const id of [...this._snakeFoodIds]) {
+ if (!ids.has(id)) this._snakeFoodIds.delete(id);
+ }
+ for (const id of ids) {
+ if (id === "me" || eaten.has(id) || PONG_NODE_IDS.includes(id)) continue;
+ this._snakeFoodIds.add(id);
}
},
async init() {
@@ -1013,8 +1479,17 @@ export default {
}
});
+ this.refreshPhysicsEnabled();
+
this.network.on("dragStart", (params) => {
- if ((this.enableBouncingBalls || this.enableOrbit) && params.nodes.length > 0) {
+ if (
+ (this.enableBouncingBalls ||
+ this.enableOrbit ||
+ this.enableFallingSkies ||
+ this.enableSnake ||
+ this.enablePong) &&
+ params.nodes.length > 0
+ ) {
this._draggingNodeId = params.nodes[0];
this.network.setOptions({ physics: { enabled: false } });
}
@@ -1031,9 +1506,7 @@ export default {
node.x = canvasPos.x;
node.y = canvasPos.y;
}
- } else if (this.enableOrbit) {
- // For orbit mode, just update the node position in vis-network DataSet
- // though it might be overwritten by orbit animation loop
+ } else if (this.enableOrbit || this.enableFallingSkies || this.enableSnake || this.enablePong) {
this.nodes.update({ id: this._draggingNodeId, x: canvasPos.x, y: canvasPos.y });
}
}
@@ -1041,6 +1514,7 @@ export default {
this.network.on("dragEnd", () => {
this._draggingNodeId = null;
+ this.refreshPhysicsEnabled();
});
this.network.on("zoom", () => {
@@ -1064,6 +1538,7 @@ export default {
}
},
async onAutoReload() {
+ if (this.enableSnake || this.enablePong) return;
if (!this.autoReload || this.isUpdating || this.isLoading) return;
this.isUpdating = true;
try {
@@ -1073,7 +1548,8 @@ export default {
}
},
updateLOD() {
- if (!this.network) return;
+ if (!this.network || this.enableSnake || this.enablePong) return;
+ if (typeof this.network.getScale !== "function") return;
const scale = this.network.getScale();
let newLOD = "high";
if (scale < 0.2) {
@@ -1163,16 +1639,49 @@ export default {
const processedNodeIds = new Set();
const processedEdgeIds = new Set();
+ const posById = {};
+ const prevIds = new Set(this.lastVizKeys);
+
+ if (!this.enableFallingSkies) {
+ this._fallingById = new Map();
+ if (!this._fallingPendingIds) {
+ this._fallingPendingIds = new Set();
+ } else {
+ this._fallingPendingIds.clear();
+ }
+ } else {
+ if (!this._fallingById) {
+ this._fallingById = new Map();
+ }
+ if (!this._fallingPendingIds) {
+ this._fallingPendingIds = new Set();
+ }
+ }
+
+ const allowAnnounceFall = this.enableFallingSkies && this.vizHadOneLayout;
+
+ const existingNodeIds = this.nodes.getIds();
+ if (this.network) {
+ const snap = this.network.getPositions(existingNodeIds);
+ if (snap) {
+ for (const id of existingNodeIds) {
+ const p = snap[id];
+ if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) {
+ posById[id] = { x: p.x, y: p.y };
+ }
+ }
+ }
+ }
+
const isDarkMode = document.documentElement.classList.contains("dark");
const fontColor = isDarkMode ? "#ffffff" : "#000000";
- // search filter helper
const searchLower = this.searchQuery.toLowerCase();
const matchesSearch = (text) => !this.searchQuery || (text && text.toLowerCase().includes(searchLower));
- // Add me
const meLabel = this.config?.display_name ?? "Local Node";
if (matchesSearch(meLabel) || matchesSearch(this.config?.identity_hash)) {
+ const mp = this.pickStablePosition("me", posById, () => ({ x: 0, y: 0 }));
let meNode = {
id: "me",
group: "me",
@@ -1185,135 +1694,148 @@ export default {
title: `Local Node: ${meLabel}\nIdentity: ${this.config?.identity_hash ?? "Unknown"}`,
color: this.nodeColor("#3b82f6", isDarkMode ? "#1e40af" : "#eff6ff"),
font: { color: fontColor, size: 16, bold: true },
- x: 0,
- y: 0,
+ x: mp.x,
+ y: mp.y,
};
meNode = { ...meNode, ...this.getNodeLODProps(meNode, this.currentLOD) };
this.nodes.update([meNode]);
processedNodeIds.add("me");
}
- // Add interfaces
const interfaceNodes = [];
const interfaceEdges = [];
- const interfaceCount = this.interfaces.length;
- const radius = 400; // Start interfaces at 400px from center
+ const ifaceEntries = [];
+ const radius = 400;
- for (let idx = 0; idx < interfaceCount; idx++) {
+ for (let idx = 0; idx < this.interfaces.length; idx++) {
const entry = this.interfaces[idx];
+ if (!this.showDisabledInterfaces && !entry.status) {
+ continue;
+ }
let label = entry.interface_name ?? entry.name;
if (entry.type === "LocalServerInterface" || entry.parent_interface_name != null) {
label = entry.name;
}
-
if (matchesSearch(label) || matchesSearch(entry.name)) {
- // Distribute interfaces in a circle
- const angle = (idx / interfaceCount) * 2 * Math.PI;
- const initialX = Math.cos(angle) * radius;
- const initialY = Math.sin(angle) * radius;
-
- let interfaceNode = {
- id: entry.name,
- group: "interface",
- label: label,
- title: `${entry.name}\nState: ${entry.status ? "Online" : "Offline"}\nBitrate: ${Utils.formatBitsPerSecond(entry.bitrate)}\nTX: ${Utils.formatBytes(entry.txb)}\nRX: ${Utils.formatBytes(entry.rxb)}`,
- size: 35,
- _originalSize: 35,
- shape: "circularImage",
- _originalShape: "circularImage",
- image: entry.status
- ? "/assets/images/network-visualiser/interface_connected.png"
- : "/assets/images/network-visualiser/interface_disconnected.png",
- color: this.nodeColor(entry.status ? "#10b981" : "#ef4444", isDarkMode ? "#064e3b" : "#ecfdf5"),
- font: { color: fontColor, size: 12, bold: true },
- x: initialX,
- y: initialY,
- };
- interfaceNode = { ...interfaceNode, ...this.getNodeLODProps(interfaceNode, this.currentLOD) };
- interfaceNodes.push(interfaceNode);
- processedNodeIds.add(entry.name);
-
- const edgeId = `me~${entry.name}`;
- interfaceEdges.push({
- id: edgeId,
- from: "me",
- to: entry.name,
- color: entry.status ? (isDarkMode ? "#065f46" : "#10b981") : isDarkMode ? "#7f1d1d" : "#ef4444",
- width: 3,
- length: 200,
- arrows: { to: { enabled: true, scaleFactor: 0.5 } },
- hidden: this.enableOrbit,
- });
- processedEdgeIds.add(edgeId);
+ ifaceEntries.push({ entry, label });
}
}
- if (interfaceNodes.length > 0) this.nodes.update(interfaceNodes);
- if (interfaceEdges.length > 0) this.edges.update(interfaceEdges);
-
- const discoveredNodes = [];
- const discoveredEdges = [];
- for (const disc of this.discoveredInterfaces) {
- const discId = `discovered~${disc.discovery_hash || disc.name}`;
- const discLabel = disc.name || disc.reachable_on || "Unknown";
- if (
- !matchesSearch(discLabel) &&
- !matchesSearch(disc.reachable_on) &&
- !matchesSearch(disc.transport_id)
- ) {
- continue;
- }
-
- if (this.hopFilterMax != null && disc.hops != null && disc.hops > this.hopFilterMax) {
- continue;
- }
- const isConnected = this.discoveredActive.some((a) => {
- const aHost = a.target_host || a.remote || a.listen_ip;
- const aPort = a.target_port || a.listen_port;
- return aHost && aPort && disc.reachable_on === aHost && String(disc.port) === String(aPort);
- });
-
- const angle = Math.random() * 2 * Math.PI;
- const dist = 800 + Math.random() * 200;
- let discNode = {
- id: discId,
- group: "discovered",
- label: discLabel,
- title: `Discovered: ${discLabel}\nType: ${disc.type || "Unknown"}\nHops: ${disc.hops ?? "?"}\nStatus: ${isConnected ? "Connected" : disc.status || "Available"}${disc.reachable_on ? `\nAddress: ${disc.reachable_on}:${disc.port}` : ""}`,
- size: 25,
- _originalSize: 25,
+ const nIface = ifaceEntries.length;
+ for (let j = 0; j < nIface; j++) {
+ const { entry, label } = ifaceEntries[j];
+ const angle = nIface > 0 ? (j / nIface) * 2 * Math.PI : 0;
+ const initialX = Math.cos(angle) * radius;
+ const initialY = Math.sin(angle) * radius;
+ const pos = this.pickStablePosition(entry.name, posById, () => ({ x: initialX, y: initialY }));
+
+ let interfaceNode = {
+ id: entry.name,
+ group: "interface",
+ label: label,
+ title: `${entry.name}\nState: ${entry.status ? "Online" : "Offline"}\nBitrate: ${Utils.formatBitsPerSecond(entry.bitrate)}\nTX: ${Utils.formatBytes(entry.txb)}\nRX: ${Utils.formatBytes(entry.rxb)}`,
+ size: 35,
+ _originalSize: 35,
shape: "circularImage",
_originalShape: "circularImage",
- image: isConnected
+ image: entry.status
? "/assets/images/network-visualiser/interface_connected.png"
: "/assets/images/network-visualiser/interface_disconnected.png",
- color: this.nodeColor(
- isConnected ? "#06b6d4" : "#64748b",
- isDarkMode ? (isConnected ? "#164e63" : "#1e293b") : isConnected ? "#ecfeff" : "#f1f5f9"
- ),
- font: { color: fontColor, size: 10 },
- x: Math.cos(angle) * dist,
- y: Math.sin(angle) * dist,
+ color: this.nodeColor(entry.status ? "#10b981" : "#ef4444", isDarkMode ? "#064e3b" : "#ecfdf5"),
+ font: { color: fontColor, size: 12, bold: true },
+ x: pos.x,
+ y: pos.y,
};
- discNode = { ...discNode, ...this.getNodeLODProps(discNode, this.currentLOD) };
- discoveredNodes.push(discNode);
- processedNodeIds.add(discId);
+ interfaceNode = { ...interfaceNode, ...this.getNodeLODProps(interfaceNode, this.currentLOD) };
+ interfaceNodes.push(interfaceNode);
+ processedNodeIds.add(entry.name);
- const edgeId = `me~${discId}`;
- discoveredEdges.push({
+ const edgeId = `me~${entry.name}`;
+ interfaceEdges.push({
id: edgeId,
from: "me",
- to: discId,
- color: {
- color: isDarkMode ? "#155e75" : "#06b6d4",
- opacity: 0.4,
- },
- width: 1,
- dashes: true,
- hidden: this.enableOrbit,
+ to: entry.name,
+ color: entry.status ? (isDarkMode ? "#065f46" : "#10b981") : isDarkMode ? "#7f1d1d" : "#ef4444",
+ width: 3,
+ length: 200,
+ arrows: { to: { enabled: true, scaleFactor: 0.5 } },
+ hidden: this.edgesHiddenForOverlayGames(),
});
processedEdgeIds.add(edgeId);
}
+ if (interfaceNodes.length > 0) this.nodes.update(interfaceNodes);
+ if (interfaceEdges.length > 0) this.edges.update(interfaceEdges);
+
+ const discoveredNodes = [];
+ const discoveredEdges = [];
+ if (this.showDiscoveredInterfaces) {
+ for (const disc of this.discoveredInterfaces) {
+ const discId = `discovered~${disc.discovery_hash || disc.name}`;
+ const discLabel = disc.name || disc.reachable_on || "Unknown";
+ if (
+ !matchesSearch(discLabel) &&
+ !matchesSearch(disc.reachable_on) &&
+ !matchesSearch(disc.transport_id)
+ ) {
+ continue;
+ }
+
+ if (this.hopFilterMax != null && disc.hops != null && disc.hops > this.hopFilterMax) {
+ continue;
+ }
+
+ const isConnected = this.discoveredActive.some((a) => {
+ const aHost = a.target_host || a.remote || a.listen_ip;
+ const aPort = a.target_port || a.listen_port;
+ return aHost && aPort && disc.reachable_on === aHost && String(disc.port) === String(aPort);
+ });
+
+ const angle = Math.random() * 2 * Math.PI;
+ const dist = 800 + Math.random() * 200;
+ const dp = this.pickStablePosition(discId, posById, () => ({
+ x: Math.cos(angle) * dist,
+ y: Math.sin(angle) * dist,
+ }));
+ let discNode = {
+ id: discId,
+ group: "discovered",
+ label: discLabel,
+ title: `Discovered: ${discLabel}\nType: ${disc.type || "Unknown"}\nHops: ${disc.hops ?? "?"}\nStatus: ${isConnected ? "Connected" : disc.status || "Available"}${disc.reachable_on ? `\nAddress: ${disc.reachable_on}:${disc.port}` : ""}`,
+ size: 25,
+ _originalSize: 25,
+ shape: "circularImage",
+ _originalShape: "circularImage",
+ image: isConnected
+ ? "/assets/images/network-visualiser/interface_connected.png"
+ : "/assets/images/network-visualiser/interface_disconnected.png",
+ color: this.nodeColor(
+ isConnected ? "#06b6d4" : "#64748b",
+ isDarkMode ? (isConnected ? "#164e63" : "#1e293b") : isConnected ? "#ecfeff" : "#f1f5f9"
+ ),
+ font: { color: fontColor, size: 10 },
+ x: dp.x,
+ y: dp.y,
+ };
+ discNode = { ...discNode, ...this.getNodeLODProps(discNode, this.currentLOD) };
+ discoveredNodes.push(discNode);
+ processedNodeIds.add(discId);
+
+ const edgeId = `me~${discId}`;
+ discoveredEdges.push({
+ id: edgeId,
+ from: "me",
+ to: discId,
+ color: {
+ color: isDarkMode ? "#155e75" : "#06b6d4",
+ opacity: 0.4,
+ },
+ width: 1,
+ dashes: true,
+ hidden: this.edgesHiddenForOverlayGames(),
+ });
+ processedEdgeIds.add(edgeId);
+ }
+ }
if (discoveredNodes.length > 0) this.nodes.update(discoveredNodes);
if (discoveredEdges.length > 0) this.edges.update(discoveredEdges);
@@ -1356,35 +1878,61 @@ export default {
}
const conversation = this.conversations[announce.destination_hash];
- const interfaceNode = this.nodes.get(entry.interface);
- let initX = 0,
- initY = 0;
+ const ip = posById[entry.interface];
+ let initX = 0;
+ let initY = 0;
- if (interfaceNode && interfaceNode.x !== undefined) {
- // Place around their parent interface with some randomness to avoid stacking
+ if (ip && Number.isFinite(ip.x) && Number.isFinite(ip.y)) {
const angle = Math.random() * 2 * Math.PI;
const dist = 150 + Math.random() * 150;
- initX = interfaceNode.x + Math.cos(angle) * dist;
- initY = interfaceNode.y + Math.sin(angle) * dist;
+ initX = ip.x + Math.cos(angle) * dist;
+ initY = ip.y + Math.sin(angle) * dist;
} else {
- // Fallback far from center
const angle = Math.random() * 2 * Math.PI;
const dist = 600 + Math.random() * 200;
initX = Math.cos(angle) * dist;
initY = Math.sin(angle) * dist;
}
+ const targetXY = this.pickStablePosition(entry.hash, posById, () => ({ x: initX, y: initY }));
+ const edgeId = `${entry.interface}~${entry.hash}`;
+ const shouldFall = allowAnnounceFall && !prevIds.has(entry.hash);
+
let node = {
id: entry.hash,
group: "announce",
size: 25,
_originalSize: 25,
_announce: announce,
+ _parentInterface: entry.interface,
font: { color: fontColor, size: 11 },
- x: initX,
- y: initY,
+ x: targetXY.x,
+ y: targetXY.y,
};
+ if (shouldFall) {
+ this._fallingPendingIds.add(entry.hash);
+ let topY = targetXY.y - 1100;
+ const container = document.getElementById("network");
+ if (container && this.network) {
+ const scale = this.network.getScale();
+ const vp = this.network.getViewPosition();
+ const halfH = container.clientHeight / (2 * scale);
+ topY = vp.y - halfH - 60;
+ }
+ node.x = targetXY.x;
+ node.y = topY;
+ posById[entry.hash] = { x: targetXY.x, y: targetXY.y };
+ this._fallingById.set(entry.hash, {
+ tx: targetXY.x,
+ ty: targetXY.y,
+ x: targetXY.x,
+ y: topY,
+ vy: 0,
+ edgeIds: [edgeId],
+ });
+ }
+
node.label = displayName;
node.title = `${displayName}\nAspect: ${announce.aspect}\nHops: ${entry.hops}\nVia: ${entry.interface}\nLast Seen: ${Utils.convertDateTimeToLocalDateTimeString(new Date(announce.updated_at))}`;
@@ -1435,7 +1983,6 @@ export default {
batchNodes.push(node);
processedNodeIds.add(node.id);
- const edgeId = `${entry.interface}~${entry.hash}`;
batchEdges.push({
id: edgeId,
from: entry.interface,
@@ -1453,7 +2000,7 @@ export default {
},
width: entry.hops === 1 ? 2 : 1,
dashes: entry.hops > 1,
- hidden: this.enableOrbit,
+ hidden: this.edgeHiddenForMode(entry.hash),
});
processedEdgeIds.add(edgeId);
}
@@ -1471,6 +2018,12 @@ export default {
if (this.abortController.signal.aborted) return;
}
+ if (this.enablePong) {
+ for (const id of PONG_NODE_IDS) {
+ processedNodeIds.add(id);
+ }
+ }
+
// Cleanup: remove nodes/edges that are no longer in the network
const nodesToRemove = this.nodes.getIds().filter((id) => !processedNodeIds.has(id));
if (nodesToRemove.length > 0) this.nodes.remove(nodesToRemove);
@@ -1483,6 +2036,22 @@ export default {
this.currentBatch = 0;
this.totalBatches = 0;
+ this.lastVizKeys = [...processedNodeIds];
+ this.vizHadOneLayout = true;
+
+ if (this.network && !this.didDisableStabilization) {
+ this.didDisableStabilization = true;
+ this.network.setOptions({ physics: { stabilization: { enabled: false } } });
+ }
+
+ if (this.enableFallingSkies && this._fallingById && this._fallingById.size > 0) {
+ this.scheduleFallingTick();
+ }
+
+ if (this.enableSnake && this._snakeFoodIds) {
+ this.reconcileSnakeFoodAfterViz();
+ }
+
if (this.enableOrbit) {
this.startOrbit();
}

diff --git a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLegend.vue b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLegend.vue
new file mode 100644
index 00000000..e69ad388
--- /dev/null
+++ b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLegend.vue
@@ -0,0 +1,35 @@
+<template>
+ <div
+ class="absolute bottom-4 right-4 z-10 hidden sm:flex items-center gap-2 px-4 py-2 rounded-full border border-gray-200/50 dark:border-zinc-800/50 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl"
+ >
+ <div class="flex items-center gap-1.5">
+ <div class="w-3 h-3 rounded-full border-2 border-emerald-500 bg-emerald-500/20"></div>
+ <span class="text-[10px] font-bold text-gray-600 dark:text-zinc-400 uppercase">Direct</span>
+ </div>
+ <div class="w-px h-3 bg-gray-200 dark:bg-zinc-800 mx-1"></div>
+ <div class="flex items-center gap-1.5">
+ <div class="w-3 h-3 rounded-full border-2 border-blue-500/50 bg-blue-500/10"></div>
+ <span class="text-[10px] font-bold text-gray-600 dark:text-zinc-400 uppercase">Multi-Hop</span>
+ </div>
+ <div
+ v-if="showDiscoveredInterfaces && discoveredCount > 0"
+ class="w-px h-3 bg-gray-200 dark:bg-zinc-800 mx-1"
+ ></div>
+ <div v-if="showDiscoveredInterfaces && discoveredCount > 0" class="flex items-center gap-1.5">
+ <div class="w-3 h-3 rounded-full border-2 border-cyan-500/50 bg-cyan-500/10"></div>
+ <span class="text-[10px] font-bold text-gray-600 dark:text-zinc-400 uppercase"
+ >Discovered ({{ discoveredCount }})</span
+ >
+ </div>
+ </div>
+</template>
+
+<script>
+export default {
+ name: "NetworkVisualiserLegend",
+ props: {
+ showDiscoveredInterfaces: { type: Boolean, default: false },
+ discoveredCount: { type: Number, default: 0 },
+ },
+};
+</script>

diff --git a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLoadingOverlay.vue b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLoadingOverlay.vue
new file mode 100644
index 00000000..b50831a9
--- /dev/null
+++ b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLoadingOverlay.vue
@@ -0,0 +1,49 @@
+<template>
+ <div
+ v-if="isLoading"
+ class="absolute inset-0 z-20 flex items-center justify-center bg-zinc-950/10 backdrop-blur-[2px] transition-all duration-300"
+ >
+ <div
+ class="bg-white/90 dark:bg-zinc-900/90 border border-gray-200 dark:border-zinc-800 rounded-2xl px-6 py-4 flex flex-col items-center gap-3"
+ >
+ <div class="relative">
+ <div class="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"></div>
+ <div class="absolute inset-0 flex items-center justify-center">
+ <div
+ class="w-6 h-6 border-4 border-emerald-500/20 border-b-emerald-500 rounded-full animate-spin-reverse"
+ ></div>
+ </div>
+ </div>
+ <div class="text-sm font-medium text-gray-900 dark:text-zinc-100">{{ loadingStatus }}</div>
+ <div v-if="totalNodesToLoad > 0" class="w-48 space-y-2">
+ <div class="h-1.5 bg-gray-200 dark:bg-zinc-800 rounded-full overflow-hidden">
+ <div
+ class="h-full bg-blue-500 transition-all duration-300 shadow-[0_0_8px_rgba(59,130,246,0.5)]"
+ :style="{ width: `${(loadedNodesCount / totalNodesToLoad) * 100}%` }"
+ ></div>
+ </div>
+ <div
+ v-if="totalBatches > 0"
+ class="flex justify-between items-center text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider"
+ >
+ <span>{{ $t("visualiser.batch") }} {{ currentBatch }} / {{ totalBatches }}</span>
+ <span>{{ Math.round((loadedNodesCount / totalNodesToLoad) * 100) }}%</span>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+export default {
+ name: "NetworkVisualiserLoadingOverlay",
+ props: {
+ isLoading: { type: Boolean, default: false },
+ loadingStatus: { type: String, default: "" },
+ totalNodesToLoad: { type: Number, default: 0 },
+ loadedNodesCount: { type: Number, default: 0 },
+ currentBatch: { type: Number, default: 0 },
+ totalBatches: { type: Number, default: 0 },
+ },
+};
+</script>

diff --git a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
new file mode 100644
index 00000000..d72ecbe7
--- /dev/null
+++ b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
@@ -0,0 +1,252 @@
+<template>
+ <div
+ class="absolute top-2 left-2 right-2 sm:top-4 sm:left-4 sm:right-4 z-10 flex flex-col sm:flex-row gap-2 pointer-events-none"
+ >
+ <div
+ class="pointer-events-auto border border-gray-200/50 dark:border-zinc-800/50 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl rounded-2xl overflow-hidden w-full sm:min-w-[280px] sm:w-auto transition-all duration-300"
+ >
+ <div
+ class="flex items-center px-4 sm:px-5 py-3 sm:py-4 cursor-pointer hover:bg-gray-50/50 dark:hover:bg-zinc-800/50 transition-colors"
+ @click="$emit('update:isShowingControls', !isShowingControls)"
+ >
+ <div class="flex-1 flex flex-col min-w-0 mr-2">
+ <span class="font-bold text-gray-900 dark:text-zinc-100 tracking-tight truncate">{{
+ $t("visualiser.reticulum_mesh")
+ }}</span>
+ <span
+ class="text-[10px] uppercase font-bold text-gray-500 dark:text-zinc-500 tracking-widest truncate"
+ >{{ $t("visualiser.network_visualizer") }}</span
+ >
+ </div>
+ <div class="flex items-center gap-2">
+ <button
+ type="button"
+ class="inline-flex items-center justify-center w-8 h-8 sm:w-9 sm:h-9 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white transition-all active:scale-95"
+ :disabled="isUpdating || isLoading"
+ @click.stop="$emit('manual-update')"
+ >
+ <svg
+ v-if="!isUpdating && !isLoading"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="w-4 h-4 sm:w-5 sm:h-5"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
+ />
+ </svg>
+ <svg
+ v-else
+ class="animate-spin h-4 w-4 sm:w-5 sm:h-5"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ >
+ <circle
+ class="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ stroke-width="4"
+ ></circle>
+ <path
+ class="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+ ></path>
+ </svg>
+ </button>
+ <div class="w-5 sm:w-6 flex justify-center">
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ class="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 transition-transform duration-300"
+ :class="{ 'rotate-180': isShowingControls }"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </div>
+ </div>
+ </div>
+
+ <div
+ v-show="isShowingControls"
+ class="px-5 pb-5 space-y-4 animate-in fade-in slide-in-from-top-2 duration-300"
+ >
+ <div class="h-px bg-gradient-to-r from-transparent via-gray-200 dark:via-zinc-800 to-transparent"></div>
+
+ <div class="flex items-center justify-between">
+ <label
+ for="auto-reload"
+ class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
+ >Auto Update</label
+ >
+ <Toggle
+ id="auto-reload"
+ :model-value="autoReload"
+ @update:model-value="$emit('update:autoReload', $event)"
+ />
+ </div>
+
+ <div class="flex items-center justify-between">
+ <label
+ for="enable-physics"
+ class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
+ >Live Layout</label
+ >
+ <Toggle
+ id="enable-physics"
+ :model-value="enablePhysics"
+ @update:model-value="$emit('update:enablePhysics', $event)"
+ />
+ </div>
+
+ <div class="space-y-2">
+ <div class="flex items-center justify-between gap-2">
+ <label
+ for="hop-filter-slider"
+ class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
+ >{{ $t("visualiser.max_hops_filter") }}</label
+ >
+ <span
+ class="text-xs font-bold text-blue-600 dark:text-blue-400 tabular-nums min-w-[4rem] text-right"
+ >{{ hopFilterSlider === 0 ? $t("visualiser.all") : hopFilterSlider }}</span
+ >
+ </div>
+ <input
+ id="hop-filter-slider"
+ :value="hopFilterSlider"
+ type="range"
+ min="0"
+ :max="hopSliderMax"
+ step="1"
+ class="w-full h-2 rounded-lg appearance-none cursor-pointer bg-gray-200 dark:bg-zinc-700 accent-blue-600 dark:accent-blue-500"
+ @input="$emit('update:hopFilterSlider', Number($event.target.value))"
+ />
+ </div>
+
+ <div class="grid grid-cols-2 gap-3 pt-2">
+ <div
+ class="bg-gray-50/50 dark:bg-zinc-800/50 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
+ >
+ <div
+ class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-1"
+ >
+ Nodes
+ </div>
+ <div class="text-lg font-bold text-blue-600 dark:text-blue-400">{{ nodeCount }}</div>
+ </div>
+ <div
+ class="bg-gray-50/50 dark:bg-zinc-800/50 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
+ >
+ <div
+ class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-1"
+ >
+ Links
+ </div>
+ <div class="text-lg font-bold text-emerald-600 dark:text-emerald-400">{{ edgeCount }}</div>
+ </div>
+ </div>
+
+ <div
+ class="bg-zinc-950/5 dark:bg-white/5 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
+ >
+ <div class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-2">
+ Interfaces
+ </div>
+ <div class="flex items-center gap-4">
+ <div class="flex items-center gap-1.5">
+ <div class="w-2 h-2 rounded-full bg-emerald-500"></div>
+ <span class="text-xs font-bold text-gray-700 dark:text-zinc-300"
+ >{{ onlineInterfaceCount }} Online</span
+ >
+ </div>
+ <div class="flex items-center gap-1.5">
+ <div class="w-2 h-2 rounded-full bg-red-500"></div>
+ <span class="text-xs font-bold text-gray-700 dark:text-zinc-300"
+ >{{ offlineInterfaceCount }} Offline</span
+ >
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="sm:ml-auto w-full sm:w-auto pointer-events-auto">
+ <div class="relative group">
+ <div
+ class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors"
+ >
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
+ <path
+ fill-rule="evenodd"
+ d="M9 3.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM2.25 10a7.75 7.75 0 1 1 14.03 4.5l3.47 3.47a.75.75 0 0 1-1.06 1.06l-3.47-3.47A7.75 7.75 0 0 1 2.25 10Z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </div>
+ <input
+ :value="searchQuery"
+ type="text"
+ :placeholder="`Search nodes (${nodeCount})...`"
+ class="block w-full sm:w-64 pl-9 pr-10 py-2.5 sm:py-3 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl border border-gray-200/50 dark:border-zinc-800/50 rounded-2xl text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-blue-500/50 sm:focus:w-80 md:max-lg:focus:w-72 lg:focus:w-80 transition-all dark:text-zinc-100 shadow-sm"
+ @input="$emit('update:searchQuery', $event.target.value)"
+ />
+ <button
+ v-if="searchQuery"
+ type="button"
+ class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-zinc-200 transition-colors"
+ @click="$emit('update:searchQuery', '')"
+ >
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
+ <path
+ d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
+ />
+ </svg>
+ </button>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import Toggle from "../../forms/Toggle.vue";
+
+export default {
+ name: "NetworkVisualiserToolbar",
+ components: { Toggle },
+ props: {
+ isShowingControls: { type: Boolean, default: true },
+ isUpdating: { type: Boolean, default: false },
+ isLoading: { type: Boolean, default: false },
+ autoReload: { type: Boolean, default: false },
+ enablePhysics: { type: Boolean, default: true },
+ hopFilterSlider: { type: Number, default: 0 },
+ hopSliderMax: { type: Number, default: 1 },
+ nodeCount: { type: Number, default: 0 },
+ edgeCount: { type: Number, default: 0 },
+ onlineInterfaceCount: { type: Number, default: 0 },
+ offlineInterfaceCount: { type: Number, default: 0 },
+ searchQuery: { type: String, default: "" },
+ },
+ emits: [
+ "update:isShowingControls",
+ "update:autoReload",
+ "update:enablePhysics",
+ "update:hopFilterSlider",
+ "update:searchQuery",
+ "manual-update",
+ ],
+};
+</script>


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────